diff --git a/packages/daemon/src/server/__tests__/daemon-runtime-app-log.test.ts b/packages/daemon/src/server/__tests__/daemon-runtime-app-log.test.ts index a102b43a0f..e6230f3a9d 100644 --- a/packages/daemon/src/server/__tests__/daemon-runtime-app-log.test.ts +++ b/packages/daemon/src/server/__tests__/daemon-runtime-app-log.test.ts @@ -19,7 +19,7 @@ test('daemon startup awaits app-log recovery after acquiring the lock and before const acquiredLock = source.indexOf("if (acquisition.status !== 'acquired')"); const legacyRecovery = source.indexOf('await deps.ownerLifecycle.recoverLegacyAppLogMarkers('); const recovery = source.indexOf('await recoverAppLogResourcesAfterDaemonLock('); - const openedServers = source.indexOf('const opened = await openDaemonServers()'); + const openedServers = source.indexOf('await openDaemonServers('); expect(acquiredLock).toBeGreaterThanOrEqual(0); expect(legacyRecovery).toBeGreaterThan(acquiredLock); diff --git a/packages/daemon/src/server/http/daemon-runtime-bounded-close.test.ts b/packages/daemon/src/server/http/daemon-runtime-bounded-close.test.ts index 133f905e9f..dfc61d3450 100644 --- a/packages/daemon/src/server/http/daemon-runtime-bounded-close.test.ts +++ b/packages/daemon/src/server/http/daemon-runtime-bounded-close.test.ts @@ -1,6 +1,9 @@ import fs from 'node:fs'; +import { setTimeout as delay } from 'node:timers/promises'; +import { lifecycleDeviceRuntimeGateway } from '../../__tests__/support/test-device-runtime-gateway.ts'; import { expect, test, vi } from 'vitest'; import { createTestDeviceInventoryGateways } from '@agent-device/device-selection/inventory-gateways-fixtures'; +import type { RuntimeStateHandle } from '@agent-device/host-kit/runtime-slots'; import type { ProviderDeviceRuntime } from '@agent-device/contracts/device'; import { resolveDaemonPaths } from '@agent-device/daemon-contracts/daemon-resolution'; import { createTestHostEnvironment, mkdtempForTestSync } from '@agent-device/test-kit'; @@ -8,6 +11,8 @@ import { clearAndroidObservationFixture } from '../../__tests__/support/android- import { fakeCreateCommandSurface } from '../../__tests__/support/command-surface-fake.ts'; import { daemonRuntimeDepsFixture, + applicationLifecycleResourceFixture, + platformOwnerLifecycleFixture, pluginRegistryFixture, } from '../../__tests__/support/daemon-runtime-deps-fixture.ts'; import { daemonPlatformServicesFixture } from '../../__tests__/support/platform-services-fixture.ts'; @@ -105,6 +110,7 @@ const cleanReport: ShutdownReport = { requests: { drained: 0, abandoned: 0 }, runners: { stopped: [], handedOff: [] }, sessions: { closed: 0, failed: 0 }, + platformOwners: 'closed', plugins: { disposed: [], failed: [], pending: [] }, }; @@ -163,3 +169,49 @@ test('the executable writes unfinished work to stderr after a close, and a clean expect(unfinished).toEqual([expect.stringMatching(/1 request\(s\) ignored cancellation/)]); expect(failed.join('')).toContain('Daemon shutdown error: close blew up'); }); + +test('unfinished platform owner shutdown is bounded, reports pending and retains ownership', async () => { + const stateDir = mkdtempForTestSync('agent-device-daemon-bounded-owner-'); + let finishOwner!: () => void; + const ownerClosed = new Promise((resolve) => { + finishOwner = resolve; + }); + const clearLock = vi.fn(async () => {}); + const pluginShutdown = vi.fn(async () => []); + const disposeState = vi.fn(); + const state: RuntimeStateHandle = { run: (fn) => fn(), enter: () => {}, dispose: disposeState }; + const runtime = await startDaemonRuntime({ + host: hostFor(stateDir), + ...silent, + shutdownBudgets: { ownerMs: 20 }, + composeDeps: daemonRuntimeDepsFixture({ + deviceRuntimeGateway: { + ...lifecycleDeviceRuntimeGateway, + applicationLifecycle: applicationLifecycleResourceFixture(), + shutdown: () => ownerClosed, + }, + ownerLifecycle: platformOwnerLifecycleFixture({ clearDaemonLockConfiguration: clearLock }), + shutdownProviderRuntimes: pluginShutdown, + runtimeStates: [state], + }), + }); + const closing = runtime.close(); + try { + const report = await Promise.race([closing, delay(1_000, null)]); + expect(report).not.toBeNull(); + expect(report!.platformOwners).toBe('pending'); + await expect(runtime.closed).resolves.toBe(report); + expect(pluginShutdown).toHaveBeenCalledOnce(); + expect(clearLock).not.toHaveBeenCalled(); + expect(disposeState).not.toHaveBeenCalled(); + const paths = resolveDaemonPaths(stateDir); + expect(fs.existsSync(paths.lockPath)).toBe(true); + expect(fs.existsSync(paths.infoPath)).toBe(true); + expect(describeUnfinishedShutdownWork(report!)).toEqual([ + expect.stringContaining('platform owners were still shutting down'), + ]); + } finally { + finishOwner(); + await closing; + } +}); diff --git a/packages/daemon/src/server/http/daemon-runtime-close.test.ts b/packages/daemon/src/server/http/daemon-runtime-close.test.ts index ce3cac65ed..19b4474990 100644 --- a/packages/daemon/src/server/http/daemon-runtime-close.test.ts +++ b/packages/daemon/src/server/http/daemon-runtime-close.test.ts @@ -1,4 +1,6 @@ import fs from 'node:fs'; +import path from 'node:path'; +import * as transport from './transport.ts'; import { expect, test, vi } from 'vitest'; import { resolveDaemonPaths } from '@agent-device/daemon-contracts/daemon-resolution'; import type { ProviderDeviceRuntime } from '@agent-device/contracts/device'; @@ -64,6 +66,7 @@ test('close is idempotent and every caller receives the one report', async () => handedOff: ['handed-off-device'], }); expect(first.sessions).toEqual({ closed: 0, failed: 0 }); + expect(first.platformOwners).toBe('closed'); expect(first.providerReleases).toEqual({ completed: [], pending: [], unknown: [] }); expect(first.plugins.disposed).toEqual(['stops']); expect(first.plugins.failed).toEqual([ @@ -139,3 +142,63 @@ test('a shutdown step that rejects still releases the registration, lock and pro expect(fs.existsSync(paths.lockPath)).toBe(false); expect(fs.existsSync(paths.infoPath)).toBe(false); }); + +test('failed HTTP setup rolls back an already listening socket', async () => { + const stateDir = mkdtempForTestSync('agent-device-daemon-partial-listen-'); + const host = hostFor(stateDir); + const createSocketServer = transport.createSocketServer; + let acquired: ReturnType | undefined; + const spy = vi.spyOn(transport, 'createSocketServer').mockImplementation((handler) => { + acquired = createSocketServer(handler); + return acquired; + }); + try { + await expect( + startDaemonRuntime({ + host: createTestHostEnvironment({ + env: { + ...host.env, + AGENT_DEVICE_DAEMON_SERVER_MODE: 'dual', + AGENT_DEVICE_HTTP_AUTH_HOOK: path.join(stateDir, 'missing-hook.mjs'), + }, + }), + ...silent, + composeDeps: daemonRuntimeDepsFixture(), + }), + ).rejects.toMatchObject({ details: { hookPath: path.join(stateDir, 'missing-hook.mjs') } }); + expect(acquired).toBeDefined(); + expect(acquired!.listening).toBe(false); + expect(fs.existsSync(resolveDaemonPaths(stateDir).lockPath)).toBe(false); + } finally { + spy.mockRestore(); + acquired?.close(); + } +}); + +test('a relative HTTP auth hook uses the runtime host directory', async () => { + const stateDir = mkdtempForTestSync('agent-device-daemon-host-auth-'); + fs.writeFileSync( + path.join(stateDir, 'auth-hook.mjs'), + "export default () => ({ ok: false, message: 'host hook refused' });", + ); + const runtime = await startDaemonRuntime({ + host: createTestHostEnvironment({ + cwd: stateDir, + env: { ...hostFor(stateDir).env, AGENT_DEVICE_HTTP_AUTH_HOOK: './auth-hook.mjs' }, + }), + ...silent, + composeDeps: daemonRuntimeDepsFixture(), + }); + try { + const response = await fetch( + `http://127.0.0.1:${runtime.httpPort}/sessions/default/requests/abc123/diagnostics`, + { + headers: { authorization: `Bearer ${runtime.token}` }, + }, + ); + expect(response.status).toBe(401); + expect(await response.json()).toMatchObject({ error: 'host hook refused' }); + } finally { + await runtime.close(); + } +}); diff --git a/packages/daemon/src/server/http/daemon-runtime.ts b/packages/daemon/src/server/http/daemon-runtime.ts index 9c4b1f51af..eae6f66584 100644 --- a/packages/daemon/src/server/http/daemon-runtime.ts +++ b/packages/daemon/src/server/http/daemon-runtime.ts @@ -13,7 +13,6 @@ import { resolveDaemonPaths, resolveDaemonServerMode, } from '@agent-device/daemon-contracts/daemon-resolution'; -import { createDaemonHttpServer } from './http-server.ts'; import { trackDownloadableArtifact } from '../../resources/artifact-tracking.ts'; import { installProviderDeviceAdmission, @@ -48,6 +47,7 @@ import { type WritableOutput, createPluginClose, createStartupRollback, + settleWithinBudget, type ShutdownReport, type StartupRollback, } from './server-shutdown.ts'; @@ -62,11 +62,7 @@ import { withDaemonDiagnosticsScope } from '@agent-device/daemon-contracts/daemo import { createSessionIdleExpiry } from './daemon-session-idle-expiry.ts'; import { resolveSessionIdleExpiryMs } from '../session-idle-expiry.ts'; import { finalizeDaemonLeases } from './daemon-lease-finalizer.ts'; -import { - processOwnsActiveDeviceClaim, - reconcileOrphanedDeviceClaims, - type DeviceClaimReconciler, -} from '@agent-device/daemon-contracts/device-claims'; +import { processOwnsActiveDeviceClaim } from '@agent-device/daemon-contracts/device-claims'; import { createDaemonShutdownClaimLedger } from './daemon-shutdown-claims.ts'; import { createAudioProbeAdmissionLedger } from '@agent-device/capture-kit/audio-probe-admission-ledger'; import { createPerfCaptureAdmissionLedger } from '@agent-device/capture-kit/perf-capture-admission-ledger'; @@ -75,7 +71,7 @@ import { withCommandHost } from '@agent-device/host-kit/command'; import { cancelAllRequests } from '@agent-device/host-kit/request'; import type { RuntimeStateHandle } from '@agent-device/host-kit/runtime-slots'; import { emitDiagnostic, type ResourceDiagnostic } from '@agent-device/host-kit/diagnostics'; -import type { EnvMap, HostEnvironment } from '@agent-device/kernel/source-value'; +import type { HostEnvironment } from '@agent-device/kernel/source-value'; import { readCurrentOwnerIdentity, reapOwnedProcessRecordsAtStartup, @@ -95,12 +91,6 @@ import { type DaemonRegistrationOwner, } from '@agent-device/daemon-contracts/daemon-registration-owner'; import { watchDaemonMetadataLoss } from './daemon-metadata-loss.ts'; -import { - createSocketServer, - listenHttpServer, - listenNetServer, - type DaemonServer, -} from './transport.ts'; import { retainPngWorker } from '@agent-device/capture-kit/png-worker-client'; import { openWebSessionNames } from '../../sessions/web-session-names.ts'; import { createAppLogAdmissionLedger } from '../../resources/app-log-admission-ledger.ts'; @@ -119,6 +109,7 @@ import type { const DAEMON_LEASE_RELEASE_TIMEOUT_MS = 1_000; const LEASE_EXPIRY_SWEEP_INTERVAL_MS = 1_000; const DAEMON_PNG_WORKER_TERMINATE_TIMEOUT_MS = 1_000; +const DAEMON_PLATFORM_OWNER_SHUTDOWN_TIMEOUT_MS = 5_000; const DAEMON_PROVIDER_RELEASE_DRAIN_TIMEOUT_MS = 2_000; // A handler that ignores cancellation must not hold `close()` forever; past this it is reported // abandoned and the registration stays held while the process lives. @@ -224,7 +215,7 @@ export type DaemonRuntimeOptions = { /** The root's runtime composition, as a factory so the server owns paths, policy, and store. */ composeDeps: ComposeDaemonRuntimeDeps; /** Shutdown time budgets, for hosts and tests that need other than the defaults. */ - shutdownBudgets?: { requestDrainMs?: number; pluginMs?: number }; + shutdownBudgets?: { requestDrainMs?: number; pluginMs?: number; ownerMs?: number }; }; export type DaemonRuntimeController = { @@ -638,62 +629,6 @@ async function startDaemonRuntimeOnHost( } }); - const openDaemonServers = async (): Promise<{ - servers: DaemonServer[]; - socketPort?: number; - httpPort?: number; - }> => { - const servers: DaemonServer[] = []; - let socketPort: number | undefined; - let httpPort: number | undefined; - const startSocketServer = daemonServerMode !== 'http'; - const startHttpServer = daemonServerMode !== 'socket'; - if (startSocketServer) { - const socketServer = createSocketServer(handleRequest); - servers.push(socketServer); - socketPort = await listenNetServer(socketServer); - } - - if (startHttpServer) { - const httpServer = await createDaemonHttpServer({ - handleRequest, - leaseRegistry, - endSessionsForLease: async (leaseId) => - await endSessionsForLease(leaseId, 'LEASE_RELEASED'), - token, - retainArtifacts, - env, - // A `/rpc` flow can reject after its own catches failed to answer, leaving this server's - // refusal as the only record the failure gets. It has no request scope — that opens later, - // inside the handler — so it reports through the pair every other out-of-request daemon - // event uses: a diagnostic scoped to the daemon log, and the runtime's error stream. - onUnownedRequestFailure: (failure) => { - // The record goes first: an injected stream that throws on write must not cost `daemon.log` - // the event. The throw itself reaches the server's guard, which is the answer's owner. - // `.catch` because a detached diagnostic has nowhere to report its own failure — the same - // shape as the metadata-loss watch above. - void emitDaemonDiagnostic(logPath, 'daemon_rpc_request_flow_failed', { - code: failure.code, - message: failure.message, - }).catch(() => {}); - stderr.write(`Daemon error: ${failure.message}\n`); - }, - // #1801: the same record `DaemonError.logPath` names, addressed by its - // locator so a remote caller can fetch what it cannot read by path. - resolveRequestDiagnosticsPath: daemonPolicy?.requiredLeaseBackend - ? undefined - : (ref) => - resolveSessionRequestLogPath( - sessionStore.resolveSessionDir(ref.session), - ref.requestId, - ), - }); - servers.push(httpServer); - httpPort = await listenHttpServer(httpServer); - } - return { servers, socketPort, httpPort }; - }; - const publishDaemonInfo = (socketPort: number | undefined, httpPort: number | undefined) => { registration.publish({ socketPort, @@ -710,14 +645,6 @@ async function startDaemonRuntimeOnHost( if (httpPort) stdout.write(`AGENT_DEVICE_DAEMON_HTTP_PORT=${httpPort}\n`); }; - const closeServersBestEffort = (servers: DaemonServer[]): void => { - for (const server of servers) { - try { - server.close(() => {}); - } catch {} - } - }; - const acquisition = await tryAcquireDaemonRegistration(daemonPaths); if (acquisition.status !== 'acquired') { throw new AppError('COMMAND_FAILED', `Daemon registration ${acquisition.status}.`, { @@ -733,9 +660,7 @@ async function startDaemonRuntimeOnHost( rollback.onRollback(async () => { await finishDaemonRegistration({ registration, infoPath, logPath }); }); - let servers: DaemonServer[] = []; - rollback.onRollback(async () => { - closeServersBestEffort(servers); + rollback.onRollback(() => { stopMetadataLossWatch(); }); const startupDiagnostics: ResourceDiagnostic[] = []; @@ -788,9 +713,45 @@ async function startDaemonRuntimeOnHost( data: { error: error instanceof Error ? error.message : String(error) }, }); }); - const opened = await openDaemonServers(); - servers = opened.servers; - const { socketPort, httpPort } = opened; + const { openDaemonServers, reconcileDeviceClaimsForDaemonStartup } = + await import('./server-startup.ts'); + const { servers, socketPort, httpPort } = await openDaemonServers( + daemonServerMode, + { + handleRequest, + leaseRegistry, + endSessionsForLease: async (leaseId) => await endSessionsForLease(leaseId, 'LEASE_RELEASED'), + token, + retainArtifacts, + env, + cwd: host.cwd, + // A `/rpc` flow can reject after its own catches failed to answer, leaving this server's + // refusal as the only record the failure gets. It has no request scope — that opens later, + // inside the handler — so it reports through the pair every other out-of-request daemon + // event uses: a diagnostic scoped to the daemon log, and the runtime's error stream. + onUnownedRequestFailure: (failure) => { + // The record goes first: an injected stream that throws on write must not cost `daemon.log` + // the event. The throw itself reaches the server's guard, which is the answer's owner. + // `.catch` because a detached diagnostic has nowhere to report its own failure — the same + // shape as the metadata-loss watch above. + void emitDaemonDiagnostic(logPath, 'daemon_rpc_request_flow_failed', { + code: failure.code, + message: failure.message, + }).catch(() => {}); + stderr.write(`Daemon error: ${failure.message}\n`); + }, + // #1801: the same record `DaemonError.logPath` names, addressed by its + // locator so a remote caller can fetch what it cannot read by path. + resolveRequestDiagnosticsPath: daemonPolicy?.requiredLeaseBackend + ? undefined + : (ref) => + resolveSessionRequestLogPath( + sessionStore.resolveSessionDir(ref.session), + ref.requestId, + ), + }, + rollback, + ); publishDaemonInfo(socketPort, httpPort); stopMetadataLossWatch = armDaemonMetadataLossWatch(baseDir, infoPath, logPath, daemonIdentity); await flushDaemonStartupDiagnostics(logPath, startupDiagnostics); @@ -838,6 +799,8 @@ async function startDaemonRuntimeOnHost( // Work that ignored cancellation may still be running, so this process keeps the registration // and lock it holds: releasing them would let a second daemon drive the same devices. let abandonedRequests = 0; + let platformOwnersPending = false; + const hasUnfinishedWork = () => abandonedRequests > 0 || platformOwnersPending; const drainInFlightRequests = async (): Promise => { const total = inFlightRequests.size; if (total === 0) return { drained: 0, abandoned: 0 }; @@ -920,7 +883,13 @@ async function startDaemonRuntimeOnHost( }, }); expiredProviderLeaseReleaser.shutdown(); - await deviceRuntimeGateway.shutdown(); + platformOwnersPending = true; + const ownerClose = await settleWithinBudget( + () => deviceRuntimeGateway.shutdown(), + options.shutdownBudgets?.ownerMs ?? DAEMON_PLATFORM_OWNER_SHUTDOWN_TIMEOUT_MS, + ); + if (ownerClose.kind === 'rejected') throw ownerClose.error; + platformOwnersPending = ownerClose.kind === 'deadline'; const plugins = await pluginClose.close(); for (const failure of plugins.failed) { await emitDaemonDiagnostic(logPath, 'provider_plugin_cleanup_failed', { @@ -935,7 +904,7 @@ async function startDaemonRuntimeOnHost( releasePngWorker().catch(() => {}), sleep(DAEMON_PNG_WORKER_TERMINATE_TIMEOUT_MS), ]); - if (abandonedRequests === 0) { + if (!hasUnfinishedWork()) { registrationFinished = true; await finishDaemonRegistration({ registration, @@ -956,6 +925,7 @@ async function startDaemonRuntimeOnHost( unknown: [...unknownLeaseIds], }, requests, + platformOwners: platformOwnersPending ? 'pending' : 'closed', runners: { stopped: [...stoppedRunners], handedOff: [...handedOffRunners] }, sessions, plugins, @@ -971,14 +941,14 @@ async function startDaemonRuntimeOnHost( await Promise.allSettled([ releasePngWorker(), pluginClose.close(), - registrationFinished || abandonedRequests > 0 + registrationFinished || hasUnfinishedWork() ? undefined : finishDaemonRegistration({ registration, infoPath, logPath }), - lockConfigurationCleared || abandonedRequests > 0 + lockConfigurationCleared || hasUnfinishedWork() ? undefined : deps.ownerLifecycle.clearDaemonLockConfiguration(), ]); - runtimeContext.dispose(); + if (!hasUnfinishedWork()) runtimeContext.dispose(); } }; @@ -1016,25 +986,3 @@ async function startDaemonRuntimeOnHost( token, }; } - -async function reconcileDeviceClaimsForDaemonStartup( - logPath: string, - reconcile: DeviceClaimReconciler, - stateDir: string, - env: EnvMap, -): Promise { - await withDaemonDiagnosticsScope({ logPath }, async () => { - try { - const summary = await reconcileOrphanedDeviceClaims(reconcile, stateDir, env); - if (summary.examined > 0) { - emitDiagnostic({ phase: 'device_claim_reconcile', data: summary }); - } - } catch (error) { - emitDiagnostic({ - level: 'warn', - phase: 'device_claim_reconcile_failed', - data: { error: error instanceof Error ? error.message : String(error) }, - }); - } - }); -} diff --git a/packages/daemon/src/server/http/http-authorization.ts b/packages/daemon/src/server/http/http-authorization.ts index ee7a50daee..fc48d03636 100644 --- a/packages/daemon/src/server/http/http-authorization.ts +++ b/packages/daemon/src/server/http/http-authorization.ts @@ -130,13 +130,11 @@ async function runHttpAuthHook( return { ok: true }; } -async function loadHttpAuthHook( - env: NodeJS.ProcessEnv = process.env, -): Promise { +async function loadHttpAuthHook(env: NodeJS.ProcessEnv, cwd: string): Promise { const hookPath = env.AGENT_DEVICE_HTTP_AUTH_HOOK; if (!hookPath) return null; const exportName = env.AGENT_DEVICE_HTTP_AUTH_EXPORT || 'default'; - const resolvedPath = path.isAbsolute(hookPath) ? hookPath : path.resolve(hookPath); + const resolvedPath = path.isAbsolute(hookPath) ? hookPath : path.resolve(cwd, hookPath); let imported: Record; try { imported = (await import(pathToFileURL(resolvedPath).href)) as Record; diff --git a/packages/daemon/src/server/http/http-server.ts b/packages/daemon/src/server/http/http-server.ts index 6259b0df7d..b689d17f5b 100644 --- a/packages/daemon/src/server/http/http-server.ts +++ b/packages/daemon/src/server/http/http-server.ts @@ -76,6 +76,7 @@ export async function createDaemonHttpServer(options: { token?: string; retainArtifacts?: boolean; env?: NodeJS.ProcessEnv; + cwd?: string; /** * Resolves a request diagnostics record path for the `/sessions/.../requests/...` * route (#1801). Omitted by embedded servers with no session store; the route @@ -97,7 +98,7 @@ export async function createDaemonHttpServer(options: { (backend) => backend !== 'macos-app' || process.platform === 'darwin', ); const environment = options.env ?? process.env; - const authHook = await loadHttpAuthHook(environment); + const authHook = await loadHttpAuthHook(environment, options.cwd ?? process.cwd()); const { handleRequest, token, diff --git a/packages/daemon/src/server/http/server-shutdown.ts b/packages/daemon/src/server/http/server-shutdown.ts index 2f60dd0421..62f352a99d 100644 --- a/packages/daemon/src/server/http/server-shutdown.ts +++ b/packages/daemon/src/server/http/server-shutdown.ts @@ -71,6 +71,7 @@ export type ShutdownReport = { */ requests: { drained: number; abandoned: number }; sessions: { closed: number; failed: number }; + platformOwners: 'closed' | 'pending'; /** `pending` plugins were still shutting down when the budget ran out. */ plugins: { disposed: string[]; @@ -95,6 +96,11 @@ export function describeUnfinishedShutdownWork(report: ShutdownReport): string[] `Daemon shutdown: ${report.requests.abandoned} request(s) ignored cancellation and may still be running; the daemon registration stays held until this process exits.`, ); } + if (report.platformOwners === 'pending') { + lines.push( + 'Daemon shutdown: platform owners were still shutting down when the budget ran out; the daemon registration stays held until this process exits.', + ); + } for (const provider of report.plugins.pending) { lines.push( `Daemon shutdown: plugin ${provider} was still shutting down when the budget ran out.`, @@ -163,7 +169,7 @@ export function createPluginClose(params: { return { close: () => (closing ??= (async () => { - const outcome = await closeWithinBudget( + const outcome = await settleWithinBudget( params.close, params.timeoutMs ?? DAEMON_PLUGIN_SHUTDOWN_TIMEOUT_MS, ); @@ -182,27 +188,30 @@ export function createPluginClose(params: { pending: [], }; } - return reportCleanupOutcomes(outcome.outcomes); + return reportCleanupOutcomes(outcome.value); })()), }; } -type PluginCloseOutcome = - | { kind: 'closed'; outcomes: readonly PluginCleanupOutcome[] } +type BoundedShutdownOutcome = + | { kind: 'completed'; value: T } | { kind: 'rejected'; error: unknown } | { kind: 'deadline' }; -async function closeWithinBudget( - close: () => Promise, +/** Settles one shutdown operation at its deadline without cancelling its unfinished work. */ +export async function settleWithinBudget( + close: () => Promise, timeoutMs: number, -): Promise { +): Promise> { let deadlineTimer: ReturnType | undefined; const outcome = await Promise.race([ - close().then( - (outcomes): PluginCloseOutcome => ({ kind: 'closed', outcomes }), - (error: unknown): PluginCloseOutcome => ({ kind: 'rejected', error }), - ), - new Promise((resolve) => { + Promise.resolve() + .then(close) + .then( + (value): BoundedShutdownOutcome => ({ kind: 'completed', value }), + (error: unknown): BoundedShutdownOutcome => ({ kind: 'rejected', error }), + ), + new Promise>((resolve) => { deadlineTimer = setTimeout(() => resolve({ kind: 'deadline' }), timeoutMs); }), ]); diff --git a/packages/daemon/src/server/http/server-startup.ts b/packages/daemon/src/server/http/server-startup.ts new file mode 100644 index 0000000000..8aba0f308b --- /dev/null +++ b/packages/daemon/src/server/http/server-startup.ts @@ -0,0 +1,61 @@ +import { withDaemonDiagnosticsScope } from '@agent-device/daemon-contracts/daemon-diagnostics-scope'; +import { + reconcileOrphanedDeviceClaims, + type DeviceClaimReconciler, +} from '@agent-device/daemon-contracts/device-claims'; +import { emitDiagnostic } from '@agent-device/host-kit/diagnostics'; +import type { DaemonServerMode } from '@agent-device/kernel/contracts'; +import type { EnvMap } from '@agent-device/kernel/source-value'; +import { createDaemonHttpServer } from './http-server.ts'; +import { closeDaemonServers, type StartupRollback } from './server-shutdown.ts'; +import { + createSocketServer, + listenHttpServer, + listenNetServer, + type DaemonServer, +} from './transport.ts'; + +/** Registers each listener for rollback before the next startup operation can fail. */ +export async function openDaemonServers( + mode: DaemonServerMode, + httpOptions: Parameters[0], + rollback: StartupRollback, +): Promise<{ servers: DaemonServer[]; socketPort?: number; httpPort?: number }> { + const servers: DaemonServer[] = []; + rollback.onRollback(() => closeDaemonServers(servers)); + let socketPort: number | undefined; + let httpPort: number | undefined; + if (mode !== 'http') { + const server = createSocketServer(httpOptions.handleRequest); + servers.push(server); + socketPort = await listenNetServer(server); + } + if (mode !== 'socket') { + const server = await createDaemonHttpServer(httpOptions); + servers.push(server); + httpPort = await listenHttpServer(server); + } + return { servers, socketPort, httpPort }; +} + +export async function reconcileDeviceClaimsForDaemonStartup( + logPath: string, + reconcile: DeviceClaimReconciler, + stateDir: string, + env: EnvMap, +): Promise { + await withDaemonDiagnosticsScope({ logPath }, async () => { + try { + const summary = await reconcileOrphanedDeviceClaims(reconcile, stateDir, env); + if (summary.examined > 0) { + emitDiagnostic({ phase: 'device_claim_reconcile', data: summary }); + } + } catch (error) { + emitDiagnostic({ + level: 'warn', + phase: 'device_claim_reconcile_failed', + data: { error: error instanceof Error ? error.message : String(error) }, + }); + } + }); +} diff --git a/scripts/layering/package-entry-surface.test.ts b/scripts/layering/package-entry-surface.test.ts index 3efb9d23a4..7470d4e2f8 100644 --- a/scripts/layering/package-entry-surface.test.ts +++ b/scripts/layering/package-entry-surface.test.ts @@ -106,12 +106,15 @@ test('the entry names its exports: export * is refused', () => { "export type * from './a.ts';", "export { a } from './a.ts';\nexport type * as ns from './b.ts';", ]) { - const found = check({}, new Map([['packages/test-kit/src/index.ts', body]])); + const found = check({}, new Map([...SOURCE, ['packages/test-kit/src/index.ts', body]])); assert.equal(found.length, 1, body); assert.match(found[0]!.message, /names every export explicitly/); } assert.deepEqual( - check({}, new Map([['packages/test-kit/src/index.ts', "export { a } from './a.ts';"]])), + check( + {}, + new Map([...SOURCE, ['packages/test-kit/src/index.ts', "export { a } from './a.ts';"]]), + ), [], ); }); @@ -120,7 +123,7 @@ test('text that only mentions a star is not an export', () => { const body = "// export * from './a.ts' is forbidden here\n" + "const note = `export * from './b.ts'`;\nexport { note };\nexport { a } from './a.ts';"; - assert.deepEqual(check({}, new Map([['packages/test-kit/src/index.ts', body]])), []); + assert.deepEqual(check({}, new Map([...SOURCE, ['packages/test-kit/src/index.ts', body]])), []); }); test('the daemon entry names its exports: export * is refused', () => { @@ -154,3 +157,18 @@ test('the shared isolation walk runs under the test-kit policy', () => { assert.equal(found.length, 1); assert.match(found[0]!.message, /test-kit -> @agent-device\/daemon/); }); + +for (const dir of ['daemon', 'test-kit']) { + test(`${dir} refuses a missing or renamed entry source`, () => { + for (const renamed of [false, true]) { + const sources = new Map(SOURCE); + const file = `packages/${dir}/src/index.ts`; + sources.delete(file); + if (renamed) sources.set(`packages/${dir}/src/renamed.ts`, "export * from './a.ts';"); + const found = check({}, sources); + assert.equal(found.length, 1); + assert.equal(found[0]!.file, file); + assert.match(found[0]!.message, /entry source is missing/); + } + }); +} diff --git a/scripts/layering/package-entry-surface.ts b/scripts/layering/package-entry-surface.ts index ea83d44a16..e9332bd75d 100644 --- a/scripts/layering/package-entry-surface.ts +++ b/scripts/layering/package-entry-surface.ts @@ -24,13 +24,13 @@ import { } from './package-isolation.ts'; const TEST_KIT_DIR = TEST_KIT_ISOLATION.dir; -const TEST_KIT_ENTRY = './src/index.ts'; +const ENTRY_FILE = './src/index.ts'; const DAEMON_DIR = 'packages/daemon'; const DAEMON_PACKAGE = '@agent-device/daemon'; const ENTRY_SURFACES = [ - { packageName: TEST_KIT_PACKAGE, dir: TEST_KIT_DIR, entry: TEST_KIT_ENTRY }, - { packageName: DAEMON_PACKAGE, dir: DAEMON_DIR, entry: './src/index.ts' }, + { packageName: TEST_KIT_PACKAGE, dir: TEST_KIT_DIR, entry: ENTRY_FILE }, + { packageName: DAEMON_PACKAGE, dir: DAEMON_DIR, entry: ENTRY_FILE }, ] as const; function stringLeaves(value: unknown): string[] { @@ -87,9 +87,15 @@ function checkEntrySurface( ); } - const entryFile = `${entrySurface.dir}/src/index.ts`; + const entryFile = `${entrySurface.dir}/${entrySurface.entry.slice(2)}`; const entrySource = sources.get(entryFile); - const starLine = entrySource === undefined ? null : findStarExportLine(entryFile, entrySource); + if (entrySource === undefined) { + violations.push( + violation(entryFile, `${entrySurface.packageName} entry source is missing: ${entryFile}.`), + ); + return violations; + } + const starLine = findStarExportLine(entryFile, entrySource); if (starLine !== null) { violations.push( violation( diff --git a/src/commands/schema/cli-help-first-screen.test.ts b/src/commands/schema/cli-help-first-screen.test.ts index f11eafce1a..21aeb61feb 100644 --- a/src/commands/schema/cli-help-first-screen.test.ts +++ b/src/commands/schema/cli-help-first-screen.test.ts @@ -101,7 +101,7 @@ const FIRST_SCREEN_RULES: Record = { '9. Reopen the app bundle under test', ], foldable: [ - "Each iOS simulator capture resolves the CoreDevice display table, captures the lit panel explicitly, and normalizes density with that panel's own point scale. Do not add a screen flag to the normal loop", + "Each iOS simulator capture resolves the CoreDevice display table, selects the sole lit panel when power identifies it, and normalizes density with that panel's own point scale. There is no screen flag. When panel power names no single lit panel, the capture names CoreDevice's primary panel, which may be dark and yield an all-black PNG. Check the pose with UIHinge.status before trusting that capture. With no primary panel either, capture fails with COMMAND_FAILED and reason capture-panel-unidentified rather than pick a panel by list order.", 'The two panels are different sizes (iPhone Duo: 466x678 points closed on the outer panel, 669x951 open on the inner). A pose change therefore invalidates every ref and coordinate. Re-snapshot after any pose change and never carry coordinates or refs across', 'Expect a fold to take 10-16 seconds: each hinge read is a five-second devicectl stream, and half-open waits for the hinge to stop moving. Re-snapshot after every fold', 'fold sends a private HID hinge event inside the selected simulator', diff --git a/website/docs/docs/security-trust.md b/website/docs/docs/security-trust.md index 2b793d90a2..8dfcd65417 100644 --- a/website/docs/docs/security-trust.md +++ b/website/docs/docs/security-trust.md @@ -23,7 +23,7 @@ CLI commands run through a per-user background daemon: - Artifact uploads are size-capped, filenames are sanitized, and archive extraction rejects path-traversal entries. Artifact downloads resolve through server-side IDs, never client-supplied paths. - `install-from-source` protects the daemon host while it downloads and extracts the artifact; it does not vouch for the app inside. An app installed and launched on an iOS simulator runs as a process on the host Mac, so treat permission to send install requests as permission to run code on that Mac. -For remote or cloud deployments, you can add a custom auth hook for remotely consumable HTTP routes: set `AGENT_DEVICE_HTTP_AUTH_HOOK` to a module path that the daemon imports, and `AGENT_DEVICE_HTTP_AUTH_EXPORT` to select the export. The host-local `/admin/human-control/*` route uses the daemon token instead. The hook runs with the daemon's full privileges, so treat it as trusted code: point it only at a read-only path you control, never at a location that less-trusted users or processes can write to. Whoever controls the daemon's environment controls the hook. +For remote or cloud deployments, you can add a custom auth hook for remotely consumable HTTP routes: set `AGENT_DEVICE_HTTP_AUTH_HOOK` to a module path that the daemon imports, and `AGENT_DEVICE_HTTP_AUTH_EXPORT` to select the export. Relative hook paths resolve from the daemon host's working directory. The host-local `/admin/human-control/*` route uses the daemon token instead. The hook runs with the daemon's full privileges, so treat it as trusted code: point it only at a read-only path you control, never at a location that less-trusted users or processes can write to. Whoever controls the daemon's environment controls the hook. Lease-owner human-control RPCs pass normal authentication and tenant/lease admission. They can target only the admitted lease's device and cannot alter a host administrator's hold. Host