@@ -2,6 +2,13 @@ import assert from 'node:assert/strict';
22import fs from 'node:fs' ;
33import path from 'node:path' ;
44import { afterEach , test , vi } from 'vitest' ;
5+
6+ vi . mock ( '@agent-device/host-kit/command' , async ( importOriginal ) => ( {
7+ ...( await importOriginal < typeof import ( '@agent-device/host-kit/command' ) > ( ) ) ,
8+ runCmdDetachedMonitored : vi . fn ( ) ,
9+ } ) ) ;
10+
11+ import { runCmdDetachedMonitored } from '@agent-device/host-kit/command' ;
512import { readProcessStartTime } from '@agent-device/host-kit/process' ;
613import { readVersion } from '@agent-device/host-kit/version' ;
714import { AppError } from '@agent-device/kernel/errors' ;
@@ -15,9 +22,12 @@ import { loadDaemonPolicy } from '../../daemon-policy-file.ts';
1522import { resolveDaemonPaths } from '../../daemon-resolution.ts' ;
1623import { sendToDaemon } from '../daemon-client.ts' ;
1724
18- // ADR 0029: a caller that names a daemon policy must not reuse a daemon that enforces another one.
25+ // ADR 0029: a caller that names a daemon policy must not use a daemon that enforces another one.
26+
27+ const mockSpawnDaemon = vi . mocked ( runCmdDetachedMonitored ) ;
1928
2029afterEach ( ( ) => {
30+ mockSpawnDaemon . mockReset ( ) ;
2131 vi . unstubAllEnvs ( ) ;
2232} ) ;
2333
@@ -81,6 +91,30 @@ test('a caller naming a policy reuses a daemon that enforces the same policy', a
8191 }
8292} ) ;
8393
94+ test ( 'a caller naming a policy refuses a daemon another caller started without it' , async ( t ) => {
95+ if ( ! ( await supportsLoopbackBind ( ) ) ) {
96+ t . skip ( 'loopback listeners are not permitted in this environment' ) ;
97+ return ;
98+ }
99+ // No daemon runs yet. While this caller starts one, another caller's daemon without the policy
100+ // wins the state dir: its metadata is what startup finds.
101+ const fixture = await startReusableDaemon ( { policyDigest : undefined , publish : 'on-spawn' } ) ;
102+ try {
103+ await assert . rejects (
104+ ( ) => sendSmoke ( fixture . stateDir ) ,
105+ ( error : unknown ) => {
106+ assert . ok ( error instanceof AppError ) ;
107+ assert . equal ( error . details ?. reason , 'DAEMON_POLICY_MISMATCH' ) ;
108+ return true ;
109+ } ,
110+ ) ;
111+ assert . equal ( mockSpawnDaemon . mock . calls . length , 1 , 'this caller did start a daemon' ) ;
112+ assert . ok ( ! fixture . seenPaths . includes ( 'POST /rpc' ) , 'no command reaches the daemon' ) ;
113+ } finally {
114+ await fixture . close ( ) ;
115+ }
116+ } ) ;
117+
84118async function sendSmoke ( stateDir : string ) {
85119 return await sendToDaemon ( {
86120 session : 'default' ,
@@ -91,7 +125,10 @@ async function sendSmoke(stateDir: string) {
91125 } ) ;
92126}
93127
94- async function startReusableDaemon ( options : { policyDigest : string | undefined } ) {
128+ async function startReusableDaemon ( options : {
129+ policyDigest : string | undefined ;
130+ publish ?: 'now' | 'on-spawn' ;
131+ } ) {
95132 const stateDir = mkdtempForTestSync ( 'agent-device-policy-reuse-' ) ;
96133 const policyPath = path . join ( stateDir , 'policy.json' ) ;
97134 fs . writeFileSync (
@@ -102,20 +139,29 @@ async function startReusableDaemon(options: { policyDigest: string | undefined }
102139 const daemon = await startHttpDaemonFixture ( { via : 'http' } ) ;
103140 const paths = resolveDaemonPaths ( stateDir ) ;
104141 fs . mkdirSync ( paths . baseDir , { recursive : true } ) ;
105- fs . writeFileSync (
106- paths . infoPath ,
107- `${ JSON . stringify ( {
108- httpPort : daemon . port ,
109- transport : 'http' ,
110- token : 'local-secret' ,
111- pid : process . pid ,
112- version : readVersion ( ) ,
113- codeSignature : currentDaemonCodeSignature ( ) ,
114- processStartTime : readProcessStartTime ( process . pid ) ?? undefined ,
115- policyDigest :
116- options . policyDigest === 'matching' ? loadDaemonPolicy ( ) ?. digest : options . policyDigest ,
117- } ) } \n`,
118- ) ;
142+ const publishInfo = ( ) =>
143+ fs . writeFileSync (
144+ paths . infoPath ,
145+ `${ JSON . stringify ( {
146+ httpPort : daemon . port ,
147+ transport : 'http' ,
148+ token : 'local-secret' ,
149+ pid : process . pid ,
150+ version : readVersion ( ) ,
151+ codeSignature : currentDaemonCodeSignature ( ) ,
152+ processStartTime : readProcessStartTime ( process . pid ) ?? undefined ,
153+ policyDigest :
154+ options . policyDigest === 'matching' ? loadDaemonPolicy ( ) ?. digest : options . policyDigest ,
155+ } ) } \n`,
156+ ) ;
157+ if ( options . publish === 'on-spawn' ) {
158+ mockSpawnDaemon . mockImplementation ( ( ) => {
159+ publishInfo ( ) ;
160+ return { pid : process . pid , exited : new Promise ( ( ) => { } ) } ;
161+ } ) ;
162+ } else {
163+ publishInfo ( ) ;
164+ }
119165 return {
120166 stateDir,
121167 seenPaths : daemon . seenPaths ,
0 commit comments